package it.unibo.refolding.pico.csv;

import it.unibo.refolding.common.CommonThings;
import it.unibo.refolding.common.DoublePair;
import it.unibo.refolding.common.LinearFunction;
import it.unibo.refolding.common.MyRuntimeException;
import it.unibo.refolding.pico.PicoDataExtractor;
import it.unibo.refolding.pico.PlainDirectionData;
import it.unibo.refolding.pico.PlainPicoData;
import it.unimi.dsi.fastutil.doubles.DoubleArrayList;
import it.unimi.dsi.fastutil.doubles.DoubleArrays;
import it.unimi.dsi.fastutil.doubles.DoubleList;

import java.awt.geom.Point2D;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.commons.io.LineIterator;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.builder.ReflectionToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import org.apache.commons.lang3.tuple.ImmutablePair;
import org.apache.commons.lang3.tuple.Pair;

import com.google.common.base.Charsets;
import com.google.common.base.Preconditions;
import com.google.common.io.Files;

/**
 * When analysing such data, set laxApproachContactPoint=True and create a .cps
 * file with the content 0,0,0.
 */
public class CsvDataExtractor {

  private static final Logger logger = Logger.getLogger(CsvDataExtractor.class.getSimpleName());

  public static PlainPicoData parse(Path path) {
    try {
      return parseThrowingIo(path);
    } catch (IOException e) {
      throw new MyRuntimeException(path + " " + e.getMessage(), e);
    } catch (Throwable t) {
      logger.log(Level.WARNING, "path={0} t={1}", new Object[] {path, t});
      throw t;
    }
  }

  static class CsvPoint {
    private final double tS, zM, xM, forceN;

    public CsvPoint(double tS, double zM, double xM, double forceN) {
      Preconditions.checkArgument(tS >= 0);
      this.tS = tS;
      Preconditions.checkArgument(zM >= 0);
      this.zM = zM;
      Preconditions.checkArgument(xM >= 0);
      this.xM = xM;
      this.forceN = forceN;
    }

    public double velocityMPerS() {
      return zM / tS;
    }

    public double springConstantNPerM() {
      return forceN / (zM - xM);
    }

    public static double[] times(List<CsvPoint> points) {
      double[] a = new double[points.size()];
      for (int i = 0; i < points.size(); ++i) {
        a[i] = points.get(i).tS;
      }
      return a;
    }

    @Override
    public String toString() {
      return ReflectionToStringBuilder.toString(this, ToStringStyle.SHORT_PREFIX_STYLE);
    }

    @Override
    public int hashCode() {
      final int prime = 31;
      int result = 1;
      long temp;
      temp = Double.doubleToLongBits(forceN);
      result = prime * result + (int) (temp ^ (temp >>> 32));
      temp = Double.doubleToLongBits(tS);
      result = prime * result + (int) (temp ^ (temp >>> 32));
      temp = Double.doubleToLongBits(xM);
      result = prime * result + (int) (temp ^ (temp >>> 32));
      temp = Double.doubleToLongBits(zM);
      result = prime * result + (int) (temp ^ (temp >>> 32));
      return result;
    }

    @Override
    public boolean equals(Object obj) {
      if (this == obj)
        return true;
      if (obj == null)
        return false;
      if (getClass() != obj.getClass())
        return false;
      CsvPoint other = (CsvPoint) obj;
      if (Double.doubleToLongBits(forceN) != Double.doubleToLongBits(other.forceN))
        return false;
      if (Double.doubleToLongBits(tS) != Double.doubleToLongBits(other.tS))
        return false;
      if (Double.doubleToLongBits(xM) != Double.doubleToLongBits(other.xM))
        return false;
      if (Double.doubleToLongBits(zM) != Double.doubleToLongBits(other.zM))
        return false;
      return true;
    }
  }

  /**
   * Avoids reading all the file in-memory at once.
   */
  public static PlainPicoData parseThrowingIo(Path path) throws IOException {
    final LineIterator lineIterator;
    try {
      lineIterator = new LineIterator(Files.newReader(path.toFile(), Charsets.ISO_8859_1));
    } catch (IOException e) {
      throw new MyRuntimeException(e.getMessage(), e);
    }
    List<CsvPoint> points = extractCsvPoints(lineIterator);
    DoublePair kV = extractKAndVFromFilename(path.getFileName().toString());
    return csvPointsToPlainPicoData(points, kV);
  }

  public static PlainPicoData parse(byte[] fileContent) {
    final List<CsvPoint> points;
    LineIterator lineIterator = new LineIterator(new InputStreamReader(new ByteArrayInputStream(
      fileContent), Charsets.ISO_8859_1));
    try {
      points = extractCsvPoints(lineIterator);
    } finally {
      lineIterator.close();
    }
    return csvPointsToPlainPicoData(points, null);
  }

  private static final double zStepM = 1e-10;
  private static final double ANY_POSITIVE_DEFLECTION_LIMIT = 20;

  private static PlainPicoData csvPointsToPlainPicoData(List<CsvPoint> points, DoublePair optionalKV) {
    DoubleList ks = new DoubleArrayList(points.size());
    DoubleList vs = new DoubleArrayList(points.size());
    for (CsvPoint point : points) {
      double v = point.velocityMPerS();
      if (v > 0 & v != Double.POSITIVE_INFINITY) {
        vs.add(v);
      }
      double k = point.springConstantNPerM();
      if (k > 0 & k != Double.POSITIVE_INFINITY) {
        ks.add(k);
      }
    }
    final double k;
    if (ks.isEmpty()) {
      logger.log(Level.INFO, "Could not figure out k from {0}.", points.size());
      if (optionalKV == null) {
        return null;
      } else {
        k = optionalKV.x;
      }
    } else {
      double computedK = CommonThings.median(ks.toDoubleArray());
      if (optionalKV != null) {
        // Allow error of 0.001 N/m.
        Preconditions.checkArgument(Math.abs(computedK - optionalKV.x) < 1e-3);
        k = optionalKV.x;
      } else {
        k = computedK;
      }
    }
    final double v;
    if (vs.isEmpty()) {
      logger.log(Level.INFO, "Could not figure out v from {0} points.", points.size());
      if (optionalKV == null) {
        return null;
      } else {
        v = optionalKV.y;
      }
    } else {
      double computedV = CommonThings.median(vs.toDoubleArray());
      if (optionalKV != null) {
        // Allow error of 0.1 nm/s.
        Preconditions.checkArgument(Math.abs(computedV - optionalKV.y) < 1e-10);
        v = optionalKV.y;
      } else {
        v = computedV;
      }
    }
    double timeStepS = zStepM / v;
    if (points.isEmpty()) {
      double deflectionLsbToMetresFactor = 1 / (k * 1);
      double deflectionSensitivity = PicoDataExtractor.sensDeflSensMPerV(ANY_POSITIVE_DEFLECTION_LIMIT, deflectionLsbToMetresFactor);
      return new PlainPicoData(
        0, k, 1, 1, 1 / 1, ANY_POSITIVE_DEFLECTION_LIMIT, deflectionSensitivity, v / (1 - 0),
        PlainDirectionData.EMPTY, PlainDirectionData.EMPTY, 1, 0);
    }
    final double minZ, maxZ, zSlope, fSlope;
    final PlainDirectionData retraction;
    if (points.isEmpty()) {
      minZ = 0;
      maxZ = 1;
      zSlope = 1;
      fSlope = 1;
      retraction = PlainDirectionData.EMPTY;
    } else {
      Pair<double[], double[]> zf = interpolate(points, timeStepS);
      double[] z = zf.getLeft();
      double[] f = zf.getRight();
      /*
       * Always use minimum as zero, so that the contact point will be (0, 0).
       */
      maxZ = CommonThings.max(z);
      LinearFunction zFunction = LinearFunction.lineThroughPoints(
        new Point2D.Double(0, 0), new Point2D.Double(maxZ == 0 ? 1 : maxZ, Short.MAX_VALUE));
      int n = z.length;
      short[] zAsShort = new short[n];
      for (int i = 0; i < n; ++i) {
        double d = zFunction.evaluate(z[i]);
        Preconditions.checkState(Short.MIN_VALUE < d);
        Preconditions.checkState(-d > Short.MIN_VALUE, "d=%s", d);
        zAsShort[i] = (short) Math.round(-d);
      }
      double minF = CommonThings.min(f);
      double maxF = CommonThings.max(f);
      double maxAbsF = Math.max(Math.abs(maxF), Math.abs(minF));
      LinearFunction fFunction = LinearFunction.lineThroughPoints(
        new Point2D.Double(0, 0), new Point2D.Double(maxAbsF == 0 ? 1 : maxAbsF, Short.MAX_VALUE));
      short[] fAsShort = new short[n];
      for (int i = 0; i < n; ++i) {
        double d = fFunction.evaluate(f[i]);
        Preconditions.checkState(Short.MIN_VALUE < d);
        Preconditions.checkState(-d > Short.MIN_VALUE, "d=%s", d);
        fAsShort[i] = (short) Math.round(-d);
      }
      retraction = new PlainDirectionData(fAsShort, zAsShort, v);
      zSlope = zFunction.slope;
      fSlope = fFunction.slope;
      minZ = CommonThings.min(z);
    }
    double deflectionLsbToMetresFactor = 1 / (k * fSlope);
    double deflectionSensitivity = PicoDataExtractor.sensDeflSensMPerV(
      ANY_POSITIVE_DEFLECTION_LIMIT, deflectionLsbToMetresFactor);
    PlainPicoData plainPicoData = new PlainPicoData(
      0, k, 1, 1, 1 / zSlope, ANY_POSITIVE_DEFLECTION_LIMIT, deflectionSensitivity, v
        / (maxZ - minZ), PlainDirectionData.EMPTY, retraction, 1, 0);
    return plainPicoData;
  }

  /**
   * Interpolates with an increasing path of times. If the times are increasing,
   * then this paths consists of the full list.
   */
  static Pair<double[], double[]> interpolate(List<CsvPoint> points, double timeStepS) {
    if (points.isEmpty()) {
      return Pair.of(DoubleArrays.EMPTY_ARRAY, DoubleArrays.EMPTY_ARRAY);
    }
    double[] times = CsvPoint.times(points);
    double startTimeS = CommonThings.min(times);
    double endTimeS = CommonThings.max(times);
    int n = 1 + (int) Math.floor((endTimeS - startTimeS) / timeStepS);
    double[] z = new double[n];
    double[] f = new double[n];
    LinearFunction[] zLines = new LinearFunction[points.size() - 1];
    for (int i = 0; i < points.size() - 1; ++i) {
      Point2D.Double a = new Point2D.Double(points.get(i).tS, points.get(i).zM);
      Point2D.Double b = new Point2D.Double(points.get(i + 1).tS, points.get(i + 1).zM);
      zLines[i] = LinearFunction.lineThroughPoints(a, b);
    }
    LinearFunction[] fLines = new LinearFunction[points.size() - 1];
    for (int i = 0; i < points.size() - 1; ++i) {
      Point2D.Double a = new Point2D.Double(points.get(i).tS, points.get(i).forceN);
      Point2D.Double b = new Point2D.Double(points.get(i + 1).tS, points.get(i + 1).forceN);
      fLines[i] = LinearFunction.lineThroughPoints(a, b);
    }
    int j = 0;
    for (int i = 0; i < n; ++i) {
      double t = startTimeS + i * timeStepS;
      // We could even do j = 0 here with identical results.
      while (points.get(j).tS < t) {
        // We can never go out of range, except perhaps due to
        // rounding error in the calculation of n.
        ++j;
      }
      final double zValue, fValue;
      if (j == 0) {
        assert i == 0 : i;
        zValue = points.get(j).zM;
        fValue = points.get(j).forceN;
      } else {
        CsvPoint previous = points.get(j - 1);
        assert previous.tS < t;
        zValue = zLines[j - 1].evaluate(t);
        fValue = fLines[j - 1].evaluate(t);
      }
      z[i] = zValue;
      f[i] = fValue;
    }
    return ImmutablePair.of(z, f);
  }

  static List<CsvPoint> extractCsvPoints(Iterator<String> iterator) {
    List<CsvPoint> points = new ArrayList<>();
    while (iterator.hasNext()) {
      String line = StringUtils.trim(iterator.next());
      if (!line.isEmpty()) {
        String[] elements = StringUtils.split(line, ",");
        if (elements.length < 6) {
          logger.log(
            Level.WARNING, "Ignoring line \"{0}\" because it has only {1} fields.", new Object[] {
            line, elements.length});
        } else {
          CsvPoint point = new CsvPoint(
            Double.parseDouble(elements[5]), Double.parseDouble(elements[2]),
            Double.parseDouble(elements[1]), Double.parseDouble(elements[3]));
          points.add(point);
        }
      }
    }
    return points;
  }

  private static final String filenameRegex = String.format(
    Locale.ROOT, ".*_k_(?<k>%s)_v_(?<v>%s).*", CommonThings.FP_REGEX, CommonThings.FP_REGEX);
  private static final Pattern filenamePattern = Pattern.compile(filenameRegex);

  private static DoublePair extractKAndVFromFilename(String filename) {
    Matcher matcher = filenamePattern.matcher(filename);
    if (!matcher.matches()) {
      logger.log(Level.WARNING, "{0} does not match {1}.", new Object[] {filename, filenameRegex});
      throw new IllegalArgumentException(filename + " does not match " + filenamePattern);
    }
    double k = Double.parseDouble(matcher.group("k"));
    double v = Double.parseDouble(matcher.group("v"));
    return DoublePair.of(k, v);
  }
}
